Haibo Zhou's site

Mobile Development Articles

Creating a floating miniPlayer view for your app.

What is a floating miniPlayer? That is commonly seen in apps like Apple Music or Youtube. There is a tiny floating player below the screen, which is showing your current playing music or video as below screenshot illustrated.

 

 

Generally, that is a Container View or ViewController on top of UITabBarViewController. In this session, I would talk about how to create this container view in our app programmatically.

First, I will create a new project called "ContainerViewDemo".

image

 

Then in storyboard, I would delete the default viewController and add a UITabBarController on it. Remember to select your TabBarController as the initial one.

image

 

Under your project navigator, change the name of ViewController to TabBarViewController, and subclass it to UITabBarController. I'm doing this because I will add the container view in this root View Controller later. Remember to connect it to the TabBarController in storyboard's identity inspector. Now, your TabBarViewController would like below.

import UIKit

class TabBarViewController: UITabBarController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        
    }
}

 

Create a new View Controller MiniPlayerViewController, it will be our container View Controller, aka the floating miniPlayer. For the sake of simplity I just give a brown background color for MiniPlayerViewController.

import UIKit

class MiniPlayerViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .brown
    }
}

 

Build and run your project, you will see nothing about this MiniPlayer. This is correct, because we haven't connect it yet. Roll back to our TabBarViewController, I will add our container view here. Replace your code inside TabBarViewController with below code.

class TabBarViewController: UITabBarController {
    
    // 1
    var miniPlayer: MiniPlayerViewController = {
        let vc = MiniPlayerViewController()
        vc.view.translatesAutoresizingMaskIntoConstraints = false
        return vc
    }()
    
    var containerView: UIView = {
        let uiView = UIView()
        uiView.translatesAutoresizingMaskIntoConstraints = false
        return uiView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 2
        addChildView()
        
        // 3
        setConstraints()
    }
    
    func addChildView() {
        view.addSubview(containerView)
        addChild(miniPlayer)
        containerView.addSubview(miniPlayer.view)
        miniPlayer.didMove(toParent: self)
    }
    
    func setConstraints() {
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            containerView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
            containerView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
            containerView.bottomAnchor.constraint(equalTo: tabBar.topAnchor),
            containerView.heightAnchor.constraint(equalToConstant: 64.0),
            
            miniPlayer.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
            miniPlayer.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
            miniPlayer.view.topAnchor.constraint(equalTo: containerView.topAnchor),
            miniPlayer.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
        ])
    }

 

Breakdown:

  1. I create two properies here, one MiniPlayerViewController instance and one containerView as UIView. I set their view's translatesAutoresizingMaskIntoConstraints = false, when we need to set our constraints manually, we set this mask to false. Otherwise, Xcode will set your constraints automatically.
  2. In setViews(), I add containerView as subView of current view, and add miniPlayer as child of current View Controller, then add miniPlayer.view as subView of containerView. Notice, the sequence is important here. And finally I call didMove(toParent: self) to let child miniPlayer knows that it has been move to parent.
  3. I set constraints of containerView and miniPlayer.view (noted! I used miniPlayer.view here). Because we don't use interface builder, we need to constraint our layout manually. Here I'm using Anchors to constraints our views, but there is alternative approaches to do it, like Frame and Visual Format Language (VFL). It's up to you what you would use, though I prefer to use anchors.

So, run it again. Boom, we could see our container view now, it lies on the top of UITabBar.

 

However, you may notice that there is an empty space on the right of item2 of UITabBar, what is it? The problem here is when we add a child viewController (MiniPlayerViewController) to UITabBar, it will assume a new sub-item is added on itself, and add a shadow item into its viewControllers array. That is not what we want, we need to remove it.

Add below lines under miniPlayer.didMove(toParent: self) in addChildView method.

// find index of miniPlayer in TabBar's viewControllers array
// and remove it.
if let childIndex = viewControllers?.firstIndex(of: miniPlayer) {
    viewControllers?.remove(at: childIndex)
}

 

Run it again, now UITabBar behaves correct, the shadow item is gone. We almost done here, just one step to go, we need to pop up a new presented view when a user tap containerView(miniPlayer).

Create a new file named PlayerViewController and replace its code as below.

class PlayerViewController: UIViewController {

    var dismissButton: UIButton = {
        let button = UIButton()
        button.translatesAutoresizingMaskIntoConstraints = false
        button.setTitle("Dismiss", for: .normal)
        button.tintColor = .white
        button.backgroundColor = .red
        button.layer.cornerRadius = 8
        button.clipsToBounds = true
        button.titleEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
        button.addTarget(self, action: #selector(dismissBTTapped), for: .touchUpInside)
        return button
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .green
        view.addSubview(dismissButton)
        
        // set constraints
        dismissButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        dismissButton.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
        dismissButton.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.26).isActive = true
    }
    
    @objc func dismissBTTapped(_ sender: Any) {
        self.dismiss(animated: true)
    }
}

 

This PlayerViewController is simple, I just put a dismiss button in it.

Replace code in MiniPlayerViewController with below code. I add a tap gesture on this View Controller so that it will pop up PlayerViewController when a user tap it.

class MiniPlayerViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .brown
        
        // add a tap gesture
        let tap = UITapGestureRecognizer(target: self, action: #selector(tapDetected))
        view.addGestureRecognizer(tap)
        view.isUserInteractionEnabled = true
    }
    
    @objc func tapDetected() {
        let vc = PlayerViewController()
        vc.modalPresentationStyle = .fullScreen
        present(vc, animated: true)
    }
}

So run it again. Now by clicking on our container view, we could open PlayerViewController, Boom!

But wait a second, after dismiss PlayerViewController, our miniPlayer bar is disappeared, what a heck! And you may found a warning in Xcode console yeilding "Presenting view controller from detached view controller is discouraged."

The problem here is iOS is complaining that some other view(the detached view) which came after the main view is presenting something. In our case, miniPlayer view( a detached view) is presenting PlayerViewController inside TabBarViewController.

To solve this problem, Delegate/Protocol pattern is suitable. By using this pattern, the popup action will be triggered inside the (childVC) miniPlayerVC although this trigger will be sent to the mainVC (UITabBarController) and be performed there.

Change code in MiniPlayerViewController.

// 1
protocol MiniPlayerDelegate {
    func presentPlayerView()
}

class MiniPlayerViewController: UIViewController {
    // 2
    var delegate: MiniPlayerDelegate?

    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .brown
        
        // add a tap gesture
        let tap = UITapGestureRecognizer(target: self, action: #selector(tapDetected))
        view.addGestureRecognizer(tap)
        view.isUserInteractionEnabled = true
    }
    
    @objc func tapDetected() {
        // 3
        guard let delegate = delegate else { return }
        delegate.presentPlayerView()
    }
}

 

Breakdown:

  1. Add a protocol called MiniPlayerDelegate and declare a protocol method presentPlayerView in it.
  2. Declare a new property type of MiniPlayerDelegate.
  3. Call presentPlayerView delegate method if that delegate property is not nil.

 

Add an extension to TabBarViewController and make it conform to MiniPlayerDelegate protocol. Then implement its protocol method presentPlayerView here.

extension TabBarViewController: MiniPlayerDelegate {
    func presentPlayerView() {
        let vc = PlayerViewController()
        vc.modalPresentationStyle = .fullScreen
        present(vc, animated: true)
    }
}

 

Add miniPlayer.delegate = self below setConstraints() inside viewDidLoad of TabBarViewController. Now your TabBarViewController should be like this.

import UIKit

class TabBarViewController: UITabBarController {
    
    // 1
    var miniPlayer: MiniPlayerViewController = {
        let vc = MiniPlayerViewController()
        vc.view.translatesAutoresizingMaskIntoConstraints = false
        return vc
    }()
    
    var containerView: UIView = {
        let uiView = UIView()
        uiView.translatesAutoresizingMaskIntoConstraints = false
        return uiView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 2
        addChildView()
        
        // 3
        setConstraints()
        miniPlayer.delegate = self
    }
    
    func addChildView() {
        view.addSubview(containerView)
        addChild(miniPlayer)
        containerView.addSubview(miniPlayer.view)
        miniPlayer.didMove(toParent: self)
        
        // find index of miniPlayer in TabBar's viewControllers array
        // and remove it.
        if let childIndex = viewControllers?.firstIndex(of: miniPlayer) {
            viewControllers?.remove(at: childIndex)
        }
    }
    
    func setConstraints() {
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            containerView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
            containerView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
            containerView.bottomAnchor.constraint(equalTo: tabBar.topAnchor),
            containerView.heightAnchor.constraint(equalToConstant: 64.0),
            
            miniPlayer.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
            miniPlayer.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
            miniPlayer.view.topAnchor.constraint(equalTo: containerView.topAnchor),
            miniPlayer.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
        ])
    }
}

extension TabBarViewController: MiniPlayerDelegate {
    func presentPlayerView() {
        let vc = PlayerViewController()
        vc.modalPresentationStyle = .fullScreen
        present(vc, animated: true)
    }
}

 

That is a lot of work indeed. Let's run it again. This time that annoying warning is gone and our container view is still there after dismissing the poped View Controller or switching the TabBar sub-item.

Above knowledge are gained from my self-taught progress, and I combine them piece by piece, this is real world constructed code, not some tiny demo tutorials code.

 

Okay, mission complete. If you find some meat in my article, feel free to share it to others.

Tagged with: